Repository navigation
feat(pm-stack): project spend attribution facts (#4822) - #5164
Conversation
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 44 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ab4c62fe30
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const tokens = sumTokens(group.sessions.map((session) => session.tokens)); | ||
| const providerID = root?.providerID ?? group.sessions[0]?.providerID ?? null; | ||
| const modelID = root?.modelID ?? group.sessions[0]?.modelID ?? null; |
There was a problem hiding this comment.
Preserve provider and model boundaries when grouping child usage
When a child session uses a different provider or model from its root, this sums both sessions' tokens but labels the entire fact with the root's provider/model. For example, an OpenAI root with an Anthropic child produces an OpenAI-tagged fact containing Anthropic consumption, while reconciliation still reports success. This loses the per-pool accounting required by doc/spend-rollups-design-1259.md (§Units), and downstream consumers cannot recover the breakdown from these facts. Retain the root's issue attribution while splitting usage facts by provider/model.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 19b78f9 (pushed as PR head e6b8d3a after merging current main): projectSpendAttribution now splits each root group's usage into one fact per (providerID, modelID) subgroup, so a child on a different provider/model no longer has its tokens summed under the root's provider/model identity. Issue/epic/project attribution stays with the root group for every subgroup, and byIssue/byEpic/byProject totals are accumulated per fact, so reconciliation still conserves every token dimension. Regression test: 'splits a root group by provider and model while keeping root issue attribution' in test/spend-attribution.test.ts (bun test: 9 pass). — queue worker lane code-z, task 11311
| const tokens = sumTokens(group.sessions.map((session) => session.tokens)); | ||
| const providerID = root?.providerID ?? group.sessions[0]?.providerID ?? null; | ||
| const modelID = root?.modelID ?? group.sessions[0]?.modelID ?? null; | ||
| const binding = root ? bindingsBySession.get(root.id) : undefined; |
There was a problem hiding this comment.
Consult exact bindings even when the root session is absent
When an exported child references a parent outside the source window, collectRootGroups retains that missing parent's ID as group.rootSessionId, but this guard prevents looking up its binding. Thus a supplied binding for that exact ID is discarded and all child usage becomes unattributed despite authoritative issue information being available. This is a realistic input because opencode-usage-export.mjs filters sessions independently by time_updated >= ?. Look up the binding using group.rootSessionId regardless of whether the root's session row exists; the missing-parent reason can still record the incomplete source context.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 19b78f9 (pushed as PR head e6b8d3a after merging current main): the binding lookup is now bindingsBySession.get(group.rootSessionId) unconditionally — the previous root ? ... : undefined guard discarded a supplied binding whenever the root session row was outside the source window. The parent-left-pool reason still records the incomplete source context when the issue is attributed this way. Regression test: 'consults the exact binding for a root outside the source window' in test/spend-attribution.test.ts (bun test: 9 pass). — queue worker lane code-z, task 11311
|
Stale-PR warden: no activity for 3 days. Resolution task: #6912 — this PR should be merged, fixed, or closed. |
…ing when root row absent Addresses both Codex P2 findings on PR #5164: - facts are now split by (providerID, modelID) within a root group so one pool's consumption is never labelled with another pool's identity, while issue attribution stays with the root group; - the exact lane binding is looked up by group.rootSessionId even when the root session row is outside the source window. Regression tests cover both cases; bun test test/spend-attribution.test.ts 9 pass.
…-attribution-projection
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e6b8d3aa50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let reason: SpendUnattributedReason | null = null; | ||
| if (group.parentLeftPool) reason = "parent-left-pool"; | ||
| else if (!issueRef) reason = root?.directory && fleetIssueNumber(root.directory) !== null ? "no-worktree" : "no-binding"; | ||
| else if (!poolItem || (poolItem.parentRef && !poolByRef.has(poolItem.parentRef))) reason = "parent-left-pool"; |
There was a problem hiding this comment.
Detect hierarchy gaps beyond the immediate parent
When an attributed issue's parent exists in the fetched pool but its grandparent is missing, this check leaves reason null despite incomplete epic resolution. resolveEpicRefs in projects.ts follows the entire ancestor chain and omits epicRef when that chain leaves the pool, so this scenario silently looks like an issue with no epic rather than missing hierarchy context. Walk the relevant ancestry cycle-safely and report parent-left-pool when a required ancestor is absent.
Useful? React with 👍 / 👎.
| test("rolls nested sessions into their root once and reconciles every token dimension", () => { | ||
| const projection = projectSpendAttribution({ |
There was a problem hiding this comment.
Add property coverage for token conservation
The projection promises independent conservation of all five token dimensions, but this test and the remaining coverage use only fixed examples. Root AGENTS.md explicitly requires property tests for meaningful invariants and says example tests do not replace them. Add generated session forests with independently varied token dimensions, provider/model splits, bindings, and missing parents, checking that every session appears exactly once and that attributed plus unattributed totals equal source totals per dimension.
AGENTS.md reference: AGENTS.md:L41-L45
Useful? React with 👍 / 👎.
| const match = /^(\d+)-(\d+)-[^/\\]+$/.exec(directoryBase(directory)); | ||
| return match ? Number(match[2]) : null; |
There was a problem hiding this comment.
Recognize the established fallback fleet worktree names
For an unbound session in /home/user/git/fleet/fb-6615-records or /home/user/git/fleet/6635-issue-6635-exec, this regex rejects the directory even when its issue uniquely exists in the pool, leaving the usage unattributed with no-binding. These naming conventions are documented and parsed in infra/cargo-target-janitor/bin/cargo_target_janitor.py:53–63, and its tests at infra/cargo-target-janitor/tests/test_cargo_target_janitor.py:371–382 explicitly cover them. Fallback mode has no broker task IDs, so queue bindings cannot be relied on to recover this attribution. Support the <task>-issue-<issue>-<slug>, fb-<issue>-<slug>, and fallback-<issue>-<slug> forms while retaining the unique-pool-match check.
Useful? React with 👍 / 👎.
Summary
Adds a pure
v1projection inpm-stack/aggregator/src/spend-attribution.ts. It conserves input, output, reasoning, cache-read, and cache-write tokens independently; failed/missing exports stay unknown (source-gap), not zero. Area is not used as a project key, and list-price estimates are not treated as dollars.Evidence
After:
bun test test/spend-attribution.test.ts— 7 pass; focused strict TypeScript check of the new module and tests — pass;git diff --check— pass.bun test: 1425 pass, 2 failures in existing/api/healthassertions intest/api-health-comments.test.ts(outside this diff). Fullbunx tsc --noEmitreports the existingtest/overview.test.tsHealthView.conflictsfixture error; the changed files pass the focused strict check.Merge Danger
Door: two-way
Blast Radius: low — new pure module and unit tests; no collector, exporter, or UI wiring changes.
Closes #4822.
Open in [code]smith · Need help on this PR? Tag
@codesmith-botwith what you need.